[FIX] script todo: ne plus laisser PyCharm manger la compilation mobile

Sur erplibre-ubuntu-2604-gnome, l'APK s'est fait tuer par le noyau. La cause
est ici : « $! » désigne xvfb-run, un script, et le tuer n'atteint ni PyCharm
ni Xvfb — l'IDE tournait encore 45 minutes après son étape, avec 1,9 Go, quand
Gradle a demandé ses 6,8 Go sur 12. C'est le GROUPE qu'on tue maintenant, et
plus rien ne survit : mesuré, 2 Go rendus.

Le .idea n'était jamais écrit non plus : 122 684 des 123 021 fichiers d'assets
du dépôt mobile épuisaient les 65 536 watches inotify. Relevées à 524288, le
projet se crée. Restent 4 Go de swap avant de compiler, et un diagnostic qui
nomme la mémoire — avec le compte de l'oom-killer — puis la limite ZIP de
65 535 entrées, sur laquelle la compilation bute désormais, en amont.

--- EN ---

On erplibre-ubuntu-2604-gnome the APK was killed by the kernel. The cause is
here: "$!" is xvfb-run, a script, and killing it reaches neither PyCharm nor
Xvfb — the IDE was still running 45 minutes after its step, holding 1.9 GB,
when Gradle asked for its 6.8 GB out of 12. The GROUP is killed now, and
nothing survives it: 2 GB given back, measured.

The .idea was never written either: 122,684 of the mobile repo's 123,021 asset
files exhausted the 65,536 inotify watches. Raised to 524288, the project gets
created. Also 4 GB of swap before building, and a diagnostic naming memory —
with the oom-killer count — then the 65,535-entry ZIP limit the build now hits,
upstream of us.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-19 05:38:01 -04:00
parent 6177b1a6a9
commit b19713e38f
3 changed files with 408 additions and 23 deletions

View file

@ -5750,8 +5750,28 @@ class TODO:
' "-Didea.suppress.statistics.report=true\\n"\n'
")\n"
"ELPYC\n"
# « setsid » donne au tout son PROPRE groupe de processus, et
# c'est le groupe qu'on tuera. Sans lui, « $! » est le PID de
# xvfb-run — un script — et le tuer n'atteint ni PyCharm, ni Xvfb,
# ni les cef_server qu'il a lancés. Mesuré sur
# erplibre-ubuntu-2604-gnome : PyCharm tournait encore 45 minutes
# plus tard avec 1,9 Go, et la compilation de l'APK qui suivait
# s'est fait tuer par le noyau, faute de mémoire.
# Les watches inotify AVANT d'ouvrir : le dépôt mobile pose
# 123 000 fichiers d'assets, et la limite par défaut est dépassée
# dès l'analyse — « inotify_add_watch(...): No space left on
# device », puis « watch root cannot be watched: -2 », puis aucun
# .idea écrit. Mesuré sur erplibre-ubuntu-2604-gnome, deux fois.
# 524288 est la valeur que JetBrains documente lui-même.
"cur=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null "
'|| echo 0); if [ "$cur" -lt 524288 ] 2>/dev/null; then '
'echo "fs.inotify.max_user_watches=524288" '
"| sudo tee /etc/sysctl.d/60-erplibre-inotify.conf >/dev/null && "
"sudo sysctl -q -p /etc/sysctl.d/60-erplibre-inotify.conf "
f'2>/dev/null; echo " {t("inotify watches raised for the IDE")}"; '
"fi; "
'PYCHARM_VM_OPTIONS="$HOME/.pycharm-headless.vmoptions" '
f"xvfb-run -a pycharm {el_dir} "
f"setsid xvfb-run -a pycharm {el_dir} "
"> /tmp/pycharm-first-run.log 2>&1 & "
"pid=$!; ok=0; "
f"for i in $(seq 1 {self._QEMU_PYCHARM_OPEN_TRIES}); do "
@ -5760,9 +5780,23 @@ class TODO:
"sleep 5; done; "
# Cinq secondes de plus : les fichiers apparaissent PENDANT leur
# écriture, et un TERM à l'instant où misc.xml naît le tronquerait.
"sleep 5; kill -TERM $pid 2>/dev/null; "
"for i in $(seq 1 12); do kill -0 $pid 2>/dev/null || break; "
"sleep 5; done; kill -KILL $pid 2>/dev/null; "
"sleep 5; kill -TERM -$pid 2>/dev/null || "
"kill -TERM $pid 2>/dev/null; "
"for i in $(seq 1 12); do kill -0 -$pid 2>/dev/null || break; "
"sleep 5; done; kill -KILL -$pid 2>/dev/null; "
# Filet, et il a sa raison d'être : ce qui survit ici mange la
# mémoire de TOUTES les étapes suivantes. On le nomme au lieu de le
# laisser courir, et on ne vise que l'IDE de /opt et l'écran :99.
# « pgrep -fc » IMPRIME 0 et rend 1 quand il ne trouve rien : le
# « || echo 0 » ajoutait un second zéro, et « 0\n0 » n'est pas
# « 0 » — le filet se déclenchait donc toujours. « wc -l » rend un
# seul nombre et un code 0.
'left=$(pgrep -f "[/]opt/pycharm|[X]vfb :99" 2>/dev/null '
"| wc -l); "
'[ "$left" = 0 ] || { '
f'echo " {t("closing what survived the first open:")} $left"; '
'pkill -f "[/]opt/pycharm" 2>/dev/null; '
'pkill -f "[X]vfb :99" 2>/dev/null; sleep 2; }; '
'[ "$ok" = 1 ]; fi; fi; } && '
f'echo " {t("project created, the install will configure it")}" '
f'|| echo " ⚠ {t("no .idea: open PyCharm once, then")} '
@ -6021,6 +6055,24 @@ class TODO:
# rien de ce qui suit ne peut synchroniser le manifeste.
("virtual environment", "ERPLibre venv missing (incomplete install)"),
("No module named", "ERPLibre venv incomplete (no pip: python3-venv)"),
# Vécu sur erplibre-ubuntu-2604-gnome : le noyau a tué le démon Gradle
# (6,8 Go de RSS sur 12 Go, sans swap), et Gradle n'en sait rien — il
# dit seulement que son démon « a disparu ». Le motif nomme la mémoire,
# et le contexte l'établit au lieu de le supposer.
# Vécu, et c'est en amont : « Too many zip entries 123678 (MAX=65535) ».
# Un APK est un ZIP classique, borné à 65 535 entrées, et le dépôt
# mobile embarque 122 684 fichiers sous assets/public/repos — des
# dépôts Odoo entiers — pour 337 fichiers qui sont l'application. Rien
# ici ne peut le corriger : c'est au projet mobile de ne pas les
# empaqueter. On le NOMME, avec le chiffre, plutôt que de laisser lire
# 5 000 lignes de Gradle.
(
"Too many zip entries",
"too many asset files for one APK (ZIP limit: 65535 entries)",
),
("daemon disappeared", "Gradle daemon killed: out of memory", "mmem"),
("Cannot allocate memory", "out of memory", "mmem"),
("Java heap space", "Gradle heap too small", "mmem"),
("FAILED", "Gradle task failed"),
)
@ -6036,13 +6088,26 @@ class TODO:
un JDK manquant — le motif venait de la revue de licences d'une étape
RÉUSSIE, trois étapes plus haut. Nommer la mauvaise cause coûte plus
cher que se taire."""
lines = "".join(
f"grep -q '{pat}' \"$d\" && {{ "
f'echo " {t("probable cause:")} {t(cause)}"; '
'rm -f "$d"; return 0; }; '
for pat, cause in self._QEMU_MOBILE_DIAG
)
lines = ""
for entry in self._QEMU_MOBILE_DIAG:
pat, cause = entry[0], entry[1]
extra = f"{entry[2]}; " if len(entry) > 2 else ""
lines += (
f"grep -q '{pat}' \"$d\" && {{ "
f'echo " {t("probable cause:")} {t(cause)}"; '
f'{extra}rm -f "$d"; return 0; }}; '
)
return (
# Le contexte mémoire, lu dans /proc et dans le journal du noyau :
# une cause « mémoire » se PROUVE, l'affirmer sans le compte de
# l'oom-killer serait une supposition de plus. Pas d'awk ni de sed
# ici : leurs programmes demandent des guillemets, et tout ceci
# voyage déjà dans un ssh entre apostrophes.
"mmem() { m=$(grep MemTotal /proc/meminfo | tr -dc 0-9); "
"w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); "
"k=$(sudo dmesg 2>/dev/null | grep -c oom-kill); "
f'echo " {t("memory:")} $((m/1024)) {t("MB RAM,")} '
f'$((w/1024)) {t("MB swap, kernel OOM kills:")} $k"; }}; '
'mdiag() { d=$(mktemp); tail -400 "$1" > "$d"; '
+ lines
+ f'echo " {t("no known pattern, last lines:")}"; '
@ -6172,6 +6237,29 @@ class TODO:
ailleurs. La lever se fait dans ce script-là, pas ici.
"""
return (
# Du swap AVANT de compiler, et ce n'est pas de la prudence : le
# démon Gradle a atteint 6,8 Go de RSS hors tas — son -Xmx1536m ne
# le borne pas — sur une VM de 12 Go SANS swap, et le noyau l'a tué
# deux fois de suite. « --max-workers=2 » n'y a rien changé :
# mesuré, le pic est passé de 10,3 à 11,2 Go. C'est donc de la marge
# qu'il faut, pas moins de parallélisme.
#
# Jamais bloquant : une image sur btrfs refuse un fichier d'échange
# ordinaire, et une compilation qui tient en mémoire n'en a pas
# besoin. On le dit et on continue.
"w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); "
'if [ "$w" -lt 2000000 ]; then '
"if sudo fallocate -l 4G /swapfile-erplibre 2>/dev/null && "
"sudo chmod 600 /swapfile-erplibre && "
"sudo mkswap -q /swapfile-erplibre >/dev/null 2>&1 && "
"sudo swapon /swapfile-erplibre 2>/dev/null; then "
"grep -q swapfile-erplibre /etc/fstab 2>/dev/null || "
'echo "/swapfile-erplibre none swap sw 0 0" '
"| sudo tee -a /etc/fstab >/dev/null; "
f'echo " {t("4 GB of swap added for the build")}"; '
"else sudo rm -f /swapfile-erplibre 2>/dev/null; "
f'echo " {t("no swap could be added; build may run short")}"; '
"fi; fi; "
f'mstep "{t("npm dependencies")}" '
f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && "
f'mstep "{t("web bundle (vite build)")}" '

View file

@ -2258,6 +2258,42 @@ TRANSLATIONS = {
"Hard signals": {"fr": "Signaux durs", "en": "Hard signals"},
"Grouped by shape": {"fr": "Regroupé par forme", "en": "Grouped by shape"},
"line": {"fr": "ligne", "en": "line"},
"inotify watches raised for the IDE": {
"fr": "watches inotify relevées pour l'IDE",
"en": "inotify watches raised for the IDE",
},
"closing what survived the first open:": {
"fr": "fermeture de ce qui a survécu à la première ouverture :",
"en": "closing what survived the first open:",
},
"Gradle daemon killed: out of memory": {
"fr": "démon Gradle tué : mémoire épuisée",
"en": "Gradle daemon killed: out of memory",
},
"out of memory": {"fr": "mémoire épuisée", "en": "out of memory"},
"Gradle heap too small": {
"fr": "tas Gradle trop petit",
"en": "Gradle heap too small",
},
"too many asset files for one APK (ZIP limit: 65535 entries)": {
"fr": "trop de fichiers d'assets pour un APK (limite ZIP : 65535"
" entrées)",
"en": "too many asset files for one APK (ZIP limit: 65535 entries)",
},
"4 GB of swap added for the build": {
"fr": "4 Go de swap ajoutés pour la compilation",
"en": "4 GB of swap added for the build",
},
"no swap could be added; build may run short": {
"fr": "swap impossible à ajouter ; la compilation peut manquer de mémoire",
"en": "no swap could be added; build may run short",
},
"memory:": {"fr": "mémoire :", "en": "memory:"},
"MB RAM,": {"fr": "Mo de RAM,", "en": "MB RAM,"},
"MB swap, kernel OOM kills:": {
"fr": "Mo de swap, tués par le noyau (OOM) :",
"en": "MB swap, kernel OOM kills:",
},
"Tick the Android emulator tool when deploying.": {
"fr": "Cochez l'outil Émulateur Android au déploiement.",
"en": "Tick the Android emulator tool when deploying.",

View file

@ -9,6 +9,7 @@ ce filtrage, et qu'un outil qui échoue ne fait pas tomber l'installation
d'ERPLibre avec lui — celle-ci ayant duré une heure.
"""
import pathlib
import subprocess
import sys
import unittest
@ -302,16 +303,62 @@ class TestMobileBuild(unittest.TestCase):
script.index("erplibre-mobile-build.log"),
)
# Banc d'essai des étapes mobiles : « mstep » est remplacé par une fonction
# qui réussit tout sauf l'étape nommée, et « sudo » par un no-op. Le contrat
# se MESURE alors au code de sortie, au lieu de se déduire de la présence
# ou de l'absence d'un « || » dans le texte — un « || echo » légitime, celui
# qui ajoute la ligne du fichier d'échange à /etc/fstab, faisait tomber
# l'ancienne version de ce test sans que rien ne soit cassé.
HARNESS = (
'mstep() { echo "-> $1"; case "$1" in *%s*) return 1;; esac; '
"return 0; }\n"
"sudo() { return 0; }\n"
)
def _run_steps(self, fail_on="RIEN", apk=False):
import os
import tempfile
with tempfile.TemporaryDirectory() as tmp:
el = pathlib.Path(tmp) / "el"
apk_dir = el / "mobile/erplibre_home_mobile/android/app/build"
(apk_dir / "outputs/apk/debug").mkdir(parents=True)
if apk:
(apk_dir / "outputs/apk/debug/app-debug.apk").write_text("x")
steps = self.todo._qemu_mobile_build_steps(str(el))
return subprocess.run(
["bash", "-c", (self.HARNESS % fail_on) + steps],
capture_output=True,
text=True,
env=dict(os.environ, HOME=tmp),
timeout=60,
)
def test_a_failed_build_fails_the_vm(self):
"""Contrat explicite : « pour que ce soit bon », l'app doit compiler.
Le bloc est donc lié par « && » et n'est PAS gardé, à la différence des
outils graphiques."""
script = self.todo._qemu_erplibre_remote_cmd(
"develop", None, False, "", "", "deb", ("mobile",)
)
tail = script[script.index("erplibre-mobile-build.log") :]
self.assertNotIn("|| true", tail)
self.assertNotIn("|| echo", tail)
Une étape en échec doit donc remonter un code non nul."""
res = self._run_steps(fail_on="gradle")
self.assertNotEqual(0, res.returncode, res.stdout[-400:])
def test_a_missing_apk_fails_even_when_gradle_returns_zero(self):
"""L'APK est la preuve, pas le code de sortie de Gradle : une tâche peut
rendre 0 sans rien produire."""
res = self._run_steps(apk=False)
self.assertNotEqual(0, res.returncode)
self.assertIn("APK", res.stdout)
def test_a_complete_build_succeeds(self):
"""L'autre sens du contrat : sans lui, un test qui échoue toujours
passerait pour un test qui vérifie quelque chose."""
res = self._run_steps(apk=True)
self.assertEqual(0, res.returncode, res.stdout[-400:])
def test_the_swap_step_alone_never_fails_the_chain(self):
"""Une image btrfs refuse un fichier d'échange ordinaire, et une
compilation qui tient en mémoire n'en a pas besoin."""
res = self._run_steps(apk=True)
self.assertEqual(0, res.returncode)
self.assertNotIn("|| true", self.todo._qemu_mobile_build_steps("/x"))
def test_the_build_covers_apk_and_tests(self):
cmd = self.todo._qemu_mobile_remote_cmd()
@ -361,7 +408,8 @@ class TestMobileBuild(unittest.TestCase):
"""Un journal de dizaines de mégaoctets ne se relit pas : le diagnostic
doit dire pourquoi."""
cmd = self.todo._qemu_mobile_remote_cmd()
for pattern, _cause in TODO._QEMU_MOBILE_DIAG:
# Une entrée peut porter un 3e élément : la commande de contexte.
for pattern in (e[0] for e in TODO._QEMU_MOBILE_DIAG):
self.assertIn(pattern, cmd, pattern)
self.assertIn('tail -12 "$1"', cmd)
@ -468,8 +516,19 @@ class TestAndroidEmulator(unittest.TestCase):
au vert alors que rien n'avait compilé."""
both = self.todo._qemu_after_remote_cmd(("mobile", "avd"))
# On neutralise les étapes : seul le CHAÎNAGE est en cause ici.
stub = 'mstep() { echo " -> $1"; return 0; }; mdiag() { :; }; '
tail = both[both.index('{ mstep "') :]
#
# « sudo » est neutralisé AUSSI, et ce n'est pas décoratif : le bloc
# ajoute un fichier d'échange de 4 Go et une ligne à /etc/fstab. Sans
# ce bouchon, un test le ferait sur la machine qui l'exécute.
stub = (
'mstep() { echo " -> $1"; return 0; }; mdiag() { :; }; '
"sudo() { return 0; }; "
)
# Ancre robuste : on part du journal mobile et on remonte à l'accolade
# qui ouvre son groupe. Chercher « { mstep » liait ce test à la forme
# de la PREMIÈRE étape, et l'ajout du swap devant l'a cassé.
marker = both.index("erplibre-mobile-build.log")
tail = both[both.rindex("{ ", 0, marker) :]
res = subprocess.run(
["bash", "-c", "set -e; " + stub + tail],
capture_output=True,
@ -490,7 +549,8 @@ class TestAndroidEmulator(unittest.TestCase):
def test_no_diagnostic_pattern_carries_an_apostrophe(self):
"""Ces motifs partent dans un « grep -q '<motif>' » : une apostrophe
fermait la chaîne et rendait tout le bloc invalide. Vécu."""
for pattern, _cause in TODO._QEMU_MOBILE_DIAG:
# Une entrée peut porter un 3e élément : la commande de contexte.
for pattern in (e[0] for e in TODO._QEMU_MOBILE_DIAG):
self.assertNotIn("'", pattern, pattern)
@ -526,7 +586,15 @@ class TestPycharmCommunity(unittest.TestCase):
class TestPycharmFirstOpen(unittest.TestCase):
"""Ouverture sans écran, pour que le .idea existe avant l'installation."""
"""Ouverture sans écran, pour que le .idea existe avant l'installation.
Deux défauts vécus sur erplibre-ubuntu-2604-gnome, tous deux silencieux :
l'IDE restait vivant 45 minutes après l'étape avec 1,9 Go — « $! » est le
PID de xvfb-run, un script, et le tuer n'atteint ni PyCharm ni Xvfb — puis
la compilation de l'APK qui suivait s'est fait tuer par le noyau. Et le
.idea n'était jamais écrit : 123 000 fichiers d'assets épuisent les watches
inotify, dont la limite valait 65 536.
"""
def setUp(self):
self.todo = TODO.__new__(TODO)
@ -581,6 +649,199 @@ class TestPycharmFirstOpen(unittest.TestCase):
)
self.assertEqual(0, res.returncode, res.stderr)
def test_the_ide_gets_its_own_process_group(self):
"""Sans « setsid », il n'y a pas de groupe à tuer."""
self.assertIn("setsid xvfb-run", self.cmd)
def test_the_whole_group_is_killed_not_just_the_wrapper(self):
"""Le signe moins est tout le correctif : « -$pid » désigne le GROUPE,
donc xvfb-run, Xvfb, pycharm et les cef_server."""
self.assertIn("kill -TERM -$pid", self.cmd)
self.assertIn("kill -KILL -$pid", self.cmd)
def test_inotify_is_raised_before_opening(self):
"""Après l'ouverture, il serait trop tard : l'analyse a déjà échoué."""
pos_watch = self.cmd.index("max_user_watches")
pos_open = self.cmd.index("setsid xvfb-run")
self.assertLess(pos_watch, pos_open)
self.assertIn("524288", self.cmd)
def test_the_leftover_count_is_a_single_number(self):
"""« pgrep -fc » imprime 0 ET rend 1 quand il ne trouve rien : le
« || echo 0 » ajoutait un second zéro, et « 0\n0 » n'est pas « 0 ».
Le filet se déclenchait donc à chaque passage."""
self.assertIn("| wc -l", self.cmd)
self.assertNotIn("pgrep -fc", self.cmd)
def test_it_is_valid_shell(self):
res = subprocess.run(
["bash", "-n"], input=self.cmd, capture_output=True, text=True
)
self.assertEqual(0, res.returncode, res.stderr)
def test_the_group_kill_really_reaps_the_children(self):
"""Le test qui compte : on rejoue l'étape avec de FAUX pycharm et
xvfb-run, celui-ci laissant un enfant derrière lui comme le vrai le
fait avec Xvfb. Rien ne doit survivre."""
import os
import tempfile
import time
with tempfile.TemporaryDirectory() as tmp:
bin_dir = pathlib.Path(tmp) / "bin"
bin_dir.mkdir()
repo = pathlib.Path(tmp) / "repo"
(repo / ".idea").mkdir(parents=True)
# .idea déjà là : l'attente sort au premier tour, et le test
# mesure la FERMETURE, pas la création.
(repo / ".idea" / "erplibre.iml").write_text("<module/>")
(repo / ".idea" / "misc.xml").write_text("<project/>")
marker = pathlib.Path(tmp) / "alive"
(bin_dir / "xvfb-run").write_text(
"#!/bin/bash\n"
# L'enfant qui survivait : un Xvfb que personne ne tuait.
f"( while true; do touch {marker}; sleep 1; done ) &\n"
'shift; exec "$@"\n'
)
(bin_dir / "pycharm").write_text(
"#!/bin/bash\nwhile true; do sleep 1; done\n"
)
(bin_dir / "sudo").write_text("#!/bin/bash\nexit 0\n")
(bin_dir / "python3").write_text("#!/bin/bash\ncat > /dev/null\n")
for name in ("xvfb-run", "pycharm", "sudo", "python3"):
(bin_dir / name).chmod(0o755)
cmd = self.todo._qemu_pycharm_project_cmd(False).replace(
self.todo._qemu_install_dir(False), str(repo)
)
env = dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin", HOME=tmp)
res = subprocess.run(
["bash", "-c", cmd],
capture_output=True,
text=True,
env=env,
timeout=180,
)
self.assertEqual(0, res.returncode, res.stdout + res.stderr)
marker.unlink(missing_ok=True)
time.sleep(3)
# L'enfant réveillait le marqueur chaque seconde : s'il vit
# encore, le fichier est revenu.
self.assertFalse(
marker.exists(),
"un enfant a survécu à la fermeture du groupe",
)
class TestMobileSwap(unittest.TestCase):
"""Le swap posé avant de compiler, et son refus de bloquer.
Mesuré : le démon Gradle a atteint 6,8 Go de RSS hors tas — son -Xmx1536m
ne le borne pas — sur une VM de 12 Go SANS swap, et le noyau l'a tué deux
fois. « --max-workers=2 » n'a rien changé : le pic est passé de 10,3 à
11,2 Go. C'est de la marge qu'il faut."""
def setUp(self):
self.todo = TODO.__new__(TODO)
self.steps = self.todo._qemu_mobile_build_steps("/tmp/el")
def test_the_swap_comes_before_the_build(self):
self.assertLess(
self.steps.index("SwapTotal"), self.steps.index("npm ci")
)
def test_it_does_nothing_when_swap_is_already_there(self):
self.assertIn("SwapTotal", self.steps)
self.assertIn("-lt 2000000", self.steps)
def test_a_failed_swap_leaves_no_stray_file(self):
"""Un fichier d'échange à moitié fait occuperait 4 Go pour rien."""
self.assertIn("rm -f /swapfile-erplibre", self.steps)
def test_it_is_valid_shell(self):
res = subprocess.run(
["bash", "-n"],
input="mstep() { :; }\n" + self.steps,
capture_output=True,
text=True,
)
self.assertEqual(0, res.returncode, res.stderr)
def test_the_swap_block_is_not_linked_by_and(self):
"""Lié par « && », un swap refusé arrêterait toute la compilation."""
head = self.steps[: self.steps.index("npm ci")]
self.assertNotIn("fi; fi && ", head)
self.assertIn("fi; fi; ", head)
class TestMobileDiagMemory(unittest.TestCase):
"""« Son démon a disparu » ne parle pas de mémoire ; le diagnostic, oui."""
def setUp(self):
self.todo = TODO.__new__(TODO)
self.cmd = self.todo._qemu_mobile_diag_cmd()
def test_the_oom_pattern_names_memory(self):
pats = {p[0]: p[1] for p in TODO._QEMU_MOBILE_DIAG}
self.assertIn("daemon disappeared", pats)
self.assertIn("memory", pats["daemon disappeared"].lower())
def test_the_zip_entry_limit_is_named(self):
"""La panne d'aujourd'hui, et elle est en amont : un APK est un ZIP
borné à 65 535 entrées, et le dépôt mobile en embarque 122 684 sous
assets/public/repos pour 337 qui sont l'application. Le diagnostic doit
le dire, pas laisser lire 5 000 lignes de Gradle."""
pats = {e[0]: e[1] for e in TODO._QEMU_MOBILE_DIAG}
self.assertIn("Too many zip entries", pats)
self.assertIn("65535", pats["Too many zip entries"])
def test_the_zip_limit_is_named_before_the_generic_gradle_failure(self):
"""« FAILED » attrape tout : placé avant, il masquerait la vraie
cause — l'ordre du tableau est le diagnostic."""
keys = [e[0] for e in TODO._QEMU_MOBILE_DIAG]
self.assertLess(
keys.index("Too many zip entries"), keys.index("FAILED")
)
def test_the_cause_is_proven_not_assumed(self):
"""Le compte de l'oom-killer et la RAM viennent avec : une cause
« mémoire » sans chiffre serait une supposition de plus."""
self.assertIn("mmem()", self.cmd)
self.assertIn("MemTotal", self.cmd)
self.assertIn("oom-kill", self.cmd)
def test_it_runs_and_names_the_cause(self):
import os
import tempfile
with tempfile.TemporaryDirectory() as tmp:
bin_dir = pathlib.Path(tmp) / "bin"
bin_dir.mkdir()
# Un dmesg qui rapporte un oom-kill, via un sudo neutre.
(bin_dir / "sudo").write_text('#!/bin/bash\nshift 0; exec "$@"\n')
(bin_dir / "dmesg").write_text(
"#!/bin/bash\necho 'oom-kill:constraint=CONSTRAINT_NONE'\n"
)
for name in ("sudo", "dmesg"):
(bin_dir / name).chmod(0o755)
log = pathlib.Path(tmp) / "build.log"
log.write_text(
"> Task :app:compressDebugAssets\n"
"Gradle build daemon disappeared unexpectedly\n"
)
res = subprocess.run(
["bash", "-c", self.cmd + f'mdiag "{log}"'],
capture_output=True,
text=True,
env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"),
timeout=60,
)
out = res.stdout
# « RAM » et « OOM » traversent les deux langues ; le chiffre,
# lui, est ce qui distingue une cause prouvée d'une supposition.
self.assertIn("RAM", out)
self.assertRegex(out, r"\(OOM\)|OOM kills")
self.assertRegex(out, r"[0-9]+")
class TestAvdStep(unittest.TestCase):
"""L'étape AVD, et ce qu'elle laisse comme piste dans le journal.