[FIX] mobile: the bundle check refused a real build

The checker knew only the pack layout. A real build ships one tar.gz per
repository, so it raised "<slug> : index.json absent" and, being guarded by
`|| exit 1`, stopped compile_and_run.sh before cap sync — no APK. Broken
since 2026-08-20 for anyone on the current mobile main: the parent half of
that work landed, the mobile half producing packs never did.

It now accepts both layouts, so whichever half lands next, it holds.

Two things got better on the way. The byte-for-byte comparison against the
source — the only check that proves fidelity rather than coherence — was
reporting zero comparisons, because no entry matched the shape it looked
for; it now compares twenty. And presence is no longer sampled: streaming
all 139 archives costs 6 s and accounts for every one of the 124 350
promised files, where a sample of twenty could not see a ghost it did not
draw.

The test guarding the ZIP limit demanded a `chunk` field on every file —
the pack layout, not the limit. It counts entries now: 278 against 65 535.
Its real-bundle class had been taught to skip on this very symptom rather
than fail; it runs again.

--- FR ---

Le vérificateur ne connaissait que la disposition en packs. Une compilation
réelle livre un tar.gz par dépôt : il levait « <slug> : index.json absent »
et, gardé par `|| exit 1`, arrêtait compile_and_run.sh avant cap sync — pas
d'APK. Cassé depuis le 2026-08-20 pour quiconque est sur le main mobile
actuel : la moitié parente de ce travail a atterri, la moitié mobile qui
produit les packs jamais.

Il accepte désormais les deux dispositions : quelle que soit la moitié qui
atterrit ensuite, il tient.

Deux choses se sont améliorées en chemin. La comparaison octet pour octet
contre la source — la seule qui prouve la fidélité et non la cohérence —
rapportait zéro comparaison, faute d'entrée à la forme attendue ; elle en
compare vingt. Et la présence n'est plus échantillonnée : traverser les 139
archives coûte 6 s et rend compte de chacun des 124 350 fichiers promis, là
où vingt tirages ne pouvaient pas voir un fantôme non tiré.

Le test qui gardait la limite du ZIP exigeait un champ `chunk` sur chaque
fichier — la disposition, pas la limite. Il compte les entrées désormais :
278 pour 65 535. Sa classe sur le vrai bundle avait appris à s'ignorer sur
ce symptôme même plutôt qu'à échouer ; elle tourne à nouveau.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 05:50:04 -04:00
parent ba4dc92ec5
commit 65a8a351a9
6 changed files with 265 additions and 58 deletions

View file

@ -189,6 +189,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet
- Une VM accueille sa connexion SSH avec les commandes propres à sa distribution
<!-- [en] -->
## Fixed
<!-- [fr] -->
## Corrigé
<!-- [en] -->
- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on `<slug> : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for
- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails
<!-- [fr] -->
- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur `<slug> : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés
- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours
<!-- [en] -->
## Removed
<!-- [fr] -->

View file

@ -89,6 +89,11 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet
- Une VM accueille sa connexion SSH avec les commandes propres à sa distribution
## Corrigé
- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur `<slug> : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés
- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours
## Retiré
- Le support d'Ubuntu 20.04 et 22.04, sur toutes les architectures : pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie pas de `g++-10` pour s390x

View file

@ -87,6 +87,11 @@ Recreating the virtual environment, use installation guide from tool `make`.
- The install dashboard shows the host RAM, a VM's used RAM and uptime, and how long a log has been silent
- A VM greets its SSH login with the distribution's own commands
## Fixed
- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on `<slug> : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for
- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails
## Removed
- Ubuntu 20.04 and 22.04 support, on every architecture: pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no `g++-10` for s390x

View file

@ -14,11 +14,14 @@ npm run build || exit 1
# Le transfert des dépôts du manifeste DANS l'application est ce qui fait
# l'intérêt de son navigateur de code hors ligne, et il peut être vide sans que
# la compilation le dise. Ces dépôts entrent en packs — un APK est un ZIP borné
# à 65535 entrées, quand un fichier par source en réclamait 123 678 — et le
# vérificateur relit un échantillon depuis les tranches pour le comparer à la
# source. Trois pannes qu'un « build OK » passe sous silence : transfert vide,
# tranche absente, index qui promet des octets que sa tranche n'a pas.
# la compilation le dise. Ces dépôts entrent dans des conteneurs — un APK est
# un ZIP borné à 65535 entrées, quand un fichier par source en réclamait
# 124 350 —
# soit une archive tar.gz par dépôt, soit des tranches pack. Le vérificateur
# accepte les deux, prouve la présence de CHAQUE fichier promis, et relit un
# échantillon octet pour octet contre la source. Quatre pannes qu'un
# « build OK » passe sous silence : transfert vide, conteneur absent, index qui
# promet un fichier que son conteneur n'a pas, octets qui diffèrent.
#
# Même vérification que l'installation d'une VM, même script : une seule
# autorité.

View file

@ -4,26 +4,35 @@
"""Le transfert des dépôts ERPLibre dans l'application mobile a-t-il eu lieu ?
L'application embarque le code des dépôts du manifeste pour les parcourir hors
ligne. Ils y entrent sous forme de PACKS : un APK est un ZIP borné à 65535
entrées, et ces dépôts pèsent plus de 120 000 fichiers — un fichier par source
faisait échouer la compilation sur « Too many zip entries ». Chaque dépôt a donc
un `index.json` qui dit, pour chaque fichier, dans quelle tranche il se trouve,
à quel offset et sur quelle longueur.
ligne. Un APK est un ZIP borné à 65535 entrées, et ces dépôts pèsent plus de
120 000 fichiers : un fichier par source faisait échouer la compilation sur
« Too many zip entries ». Deux dispositions résolvent cela, et ce script
accepte les DEUX — sans quoi il échoue sur la moitié du travail qui n'a pas
encore atterri, ce qui est arrivé :
- ARCHIVE : un `tar.gz` par dépôt, plus un `index.json` listant ses chemins.
Le manifeste porte alors `archive` et `indexUrl`.
- PACKS : des tranches `pack-NNN.bin` par dépôt, et un `index.json` qui dit
pour chaque fichier sa tranche, son offset et sa longueur. Un fichier s'y
relit sans décompresser le reste.
Ce script VÉRIFIE ce transfert, et il est fait pour être exécuté par
l'installation comme à la main :
./script/mobile/check_bundle_transfer.py [racine_du_dépôt_mobile]
Il échoue quand le transfert est vide, quand une tranche manque, ou quand un
index promet des octets que sa tranche n'a pas — trois pannes qu'un simple
« la compilation a réussi » ne dit pas.
Il échoue quand le transfert est vide, quand un conteneur manque, quand un
index promet un fichier que son conteneur n'a pas, ou quand les octets relus
diffèrent de la source — quatre pannes qu'un simple « la compilation a réussi »
ne dit pas. La présence est prouvée pour CHAQUE fichier promis ; seule la
relecture des octets se fait par échantillon.
"""
import argparse
import json
import random
import sys
import tarfile
from pathlib import Path
# En dessous, ce n'est plus un transfert : c'est un bundle vide qu'on aurait pris
@ -65,6 +74,126 @@ def read_from_pack(repo_dir: Path, entry: dict) -> bytes:
return data
# Le plugin de compilation ecrit les membres du tar prefixes par « ./ », quand
# l'index porte le chemin nu. Sans ce prefixe, extractfile ne trouve rien.
MEMBER_PREFIX = "./"
def index_path(base: Path, proj: dict) -> Path:
"""L'index du dépôt, dans l'une ou l'autre disposition."""
url = proj.get("indexUrl")
if url:
# `indexUrl` est relatif à dist/, quand `base` est dist/repos.
cand = base.parent / url
if cand.is_file():
return cand
cand = base / proj["slug"] / "index.json"
if cand.is_file():
return cand
raise FileNotFoundError(f"{proj['slug']} : index.json absent")
def archive_path(base: Path, proj: dict) -> Path:
"""Le tar.gz du dépôt, ou None si ce dépôt n'est pas en archive."""
rel = proj.get("archive")
if not rel:
return None
cand = base.parent / rel
return cand if cand.is_file() else None
def read_from_archive(
archive: Path, paths: list, promised: list = None
) -> dict:
"""Relit plusieurs fichiers en UNE passe de décompression.
Ouvrir une archive par fichier coûterait quelques secondes chacune sur le
dépôt odoo (150 Mo) : on traverse donc une seule fois en ramassant tout ce
qui est demandé.
`promised` : tous les chemins que l'index annonce pour cette archive. La
traversée étant déjà payée, on en profite pour vérifier qu'ils y sont TOUS
— un échantillon de vingt fichiers ne verrait pas un fantôme qu'il ne tire
pas, et c'est précisément ce que cette passe attrape gratuitement.
"""
wanted = {MEMBER_PREFIX + p: p for p in paths}
found = {}
seen = set()
with tarfile.open(archive, "r:gz") as tar:
for member in tar:
if member.isfile():
seen.add(member.name)
key = wanted.get(member.name)
if key is None:
continue
handle = tar.extractfile(member)
if handle is None:
raise ValueError(
f"{archive.name} : {key} n'est pas un fichier"
)
found[key] = handle.read()
missing = set(wanted.values()) - set(found)
if missing:
raise FileNotFoundError(
f"{archive.name} : {len(missing)} fichier(s) tiré(s) de l'index"
f" et absents de l'archive, dont {sorted(missing)[0]}"
)
if promised:
ghosts = {q for q in promised if MEMBER_PREFIX + q not in seen}
if ghosts:
raise FileNotFoundError(
f"{archive.name} : l'index promet {len(ghosts)} fichier(s) que"
f" l'archive n'a pas, dont {sorted(ghosts)[0]}"
)
return found
def _collect(base: Path, repos: list, report: dict) -> tuple:
"""Range chaque dépôt dans sa disposition, et valide ce qu'il promet.
Extraite de `check` pour la garder sous la complexité que flake8 accepte :
deux dispositions et leurs validations tenaient mal dans une fonction.
"""
pack_pool = []
arch_pool = {}
for proj in repos:
entries = _read_json(index_path(base, proj))
files = [e for e in entries if e.get("type") == "file"]
# Le manifeste annonce un compte : un index plus court est un transfert
# tronqué que rien d'autre ne signale.
promised = proj.get("fileCount")
if promised is not None and promised != len(files):
raise ValueError(
f"{proj['slug']} : le manifeste promet {promised} fichiers,"
f" l'index en porte {len(files)}"
)
report["files"] += len(files)
archive = archive_path(base, proj)
if archive is not None:
report["archives"] += 1
arch_pool.setdefault(archive, (proj, []))[1].extend(files)
continue
repo_dir = base / proj["slug"]
packs = list(repo_dir.glob("pack-*.bin"))
if files and not packs:
# Nommer la tranche ATTENDUE : « pack-*.bin » n'aide pas qui lit un
# journal d'installation et cherche un fichier précis.
chunks = sorted(
{e["chunk"] for e in files if e.get("chunk") is not None}
)
attendu = (
pack_path(repo_dir, chunks[0]).name
if chunks
else "pack-000.bin"
)
raise FileNotFoundError(
f"{proj['slug']} : ni archive, ni {attendu}"
)
report["packs"] += len(packs)
pack_pool += [(proj, repo_dir, e) for e in files if e.get("size")]
return pack_pool, arch_pool
def check(
mobile_root: Path, workspace: Path = None, min_files: int = None
) -> dict:
@ -87,25 +216,13 @@ def check(
report = {
"repos": len(repos),
"files": 0,
"archives": 0,
"packs": 0,
"present": 0,
"checked": 0,
"compared": 0,
}
sample_pool = []
for proj in repos:
repo_dir = base / proj["slug"]
index = repo_dir / "index.json"
if not index.is_file():
raise FileNotFoundError(f"{proj['slug']} : index.json absent")
entries = _read_json(index)
files = [
e
for e in entries
if e.get("type") == "file" and e.get("chunk") is not None
]
report["files"] += len(files)
report["packs"] += len(list(repo_dir.glob("pack-*.bin")))
sample_pool += [(proj, repo_dir, e) for e in files if e.get("size")]
pack_pool, arch_pool = _collect(base, repos, report)
if report["files"] < min_files:
raise ValueError(
@ -113,22 +230,53 @@ def check(
f" {report['repos']} dépôts (au moins {min_files} attendus)"
)
flat = [("pack", proj, repo_dir, e) for proj, repo_dir, e in pack_pool]
flat += [
("arch", proj, archive, e)
for archive, (proj, entries) in arch_pool.items()
for e in entries
]
random.seed(SEED)
for proj, repo_dir, entry in random.sample(
sample_pool, min(SAMPLE, len(sample_pool))
):
data = read_from_pack(repo_dir, entry)
sample = random.sample(flat, min(SAMPLE, len(flat)))
def compare(proj, entry, data):
report["checked"] += 1
if workspace is None:
continue
return
src = workspace / proj["path"] / entry["path"]
if not src.is_file():
continue
return
if data != src.read_bytes():
raise ValueError(
f"{proj['slug']} : {entry['path']} diffère de la source"
)
report["compared"] += 1
for kind, proj, container, entry in sample:
if kind == "pack":
compare(proj, entry, read_from_pack(container, entry))
drawn = {}
for kind, proj, container, entry in sample:
if kind == "arch":
drawn.setdefault(container, []).append(entry)
# Toutes les archives sont traversées, pas seulement celles que
# l'échantillon tire : la traversée coûte 6 s pour les 139 dépôts et elle
# prouve la présence de CHACUN des fichiers promis. Un échantillon de vingt
# ne verrait pas un fantôme qu'il ne tire pas. Les octets, eux, ne sont lus
# que pour les tirés — c'est la lecture qui coûte, pas la traversée.
for archive, (proj, entries) in arch_pool.items():
here = drawn.get(archive, [])
blobs = read_from_archive(
archive,
[e["path"] for e in here],
promised=[e["path"] for e in entries],
)
report["present"] += len(entries)
for entry in here:
compare(proj, entry, blobs[entry["path"]])
return report
@ -156,9 +304,16 @@ def main() -> int:
extra = (
f", {rep['compared']} comparés à la source" if rep["compared"] else ""
)
where = []
if rep["archives"]:
where.append(f"{rep['archives']} archives")
if rep["packs"]:
where.append(f"{rep['packs']} tranches")
proven = f", {rep['present']} présences prouvées" if rep["present"] else ""
print(
f" {rep['repos']} dépôts, {rep['files']} fichiers en"
f" {rep['packs']} tranches ({rep['checked']} relus{extra})"
f" {' et '.join(where) or 'aucun conteneur'}"
f" ({rep['checked']} relus{extra}{proven})"
)
return 0

View file

@ -265,6 +265,8 @@ class TestTheCommandLine(unittest.TestCase):
REPO = Path(__file__).resolve().parent.parent
MOBILE = REPO / "mobile" / "erplibre_home_mobile"
# Le plafond d'entrees d'un ZIP, et donc d'un APK.
ZIP_ENTRY_LIMIT = 65535
class TestTheRealBundle(unittest.TestCase):
@ -276,10 +278,11 @@ class TestTheRealBundle(unittest.TestCase):
(script/test/run_unit_test.sh) annonce la même dépendance avant de
commencer.
Ce qu'ils gardent : qu'une compilation réelle produise bien des PACKS. Un
retour au fichier-par-source ferait disparaître le champ « chunk » des
index, et la limite du ZIP reviendrait — 123 678 entrées pour un plafond de
65 535, silencieusement, jusqu'à l'APK.
Ce qu'ils gardent : qu'une compilation réelle range les sources dans des
CONTENEURS — archives tar.gz ou tranches pack, les deux dispositions
conviennent. Un retour au fichier-par-source ferait revenir la limite du
ZIP — 123 678 entrées pour un plafond de 65 535, silencieusement, jusqu'à
l'APK. C'est le nombre d'entrées qui compte, pas la forme du conteneur.
"""
@classmethod
@ -320,11 +323,18 @@ class TestTheRealBundle(unittest.TestCase):
# ce qui se lit comme une régression du transfert alors que rien
# n'était encore transféré. Un état incomplet s'IGNORE ; seule une
# incohérence entre ce qui est là et le dépôt doit échouer.
# L'index se cherche par la MÊME résolution que le vérificateur : la
# chercher ici en dur, sous `<slug>/index.json`, faisait sauter ces
# tests sur toute compilation en archives — ils regardaient ailleurs au
# lieu de vérifier.
for entree in entrees:
slug = entree.get("slug") if isinstance(entree, dict) else entree
if slug and not (cls.repos / str(slug) / "index.json").is_file():
if not isinstance(entree, dict):
continue
try:
cbt.index_path(cls.repos, entree)
except (OSError, KeyError) as exc:
raise unittest.SkipTest(
f"paquet incomplet ({slug} sans index.json) :"
f"paquet incomplet ({exc}) :"
" relancer ./mobile/compile_and_run.sh"
)
@ -332,31 +342,46 @@ class TestTheRealBundle(unittest.TestCase):
rep = cbt.check(MOBILE, REPO)
self.assertGreater(rep["repos"], 1)
self.assertGreater(rep["files"], cbt.MIN_FILES)
self.assertGreater(rep["packs"], 0)
# Peu importe la disposition : ce qui doit être vrai, c'est que les
# sources soient dans des conteneurs et non une entrée ZIP chacune.
self.assertGreater(rep["packs"] + rep["archives"], 0)
def test_a_sample_matches_the_source(self):
"""La seule vérification qui prouve un transfert FIDÈLE."""
rep = cbt.check(MOBILE, REPO)
self.assertGreater(rep["compared"], 0)
def test_the_indexes_are_packed_not_file_per_source(self):
"""Le garde-fou de la limite du ZIP : chaque fichier doit porter sa
tranche. Sans « chunk », c'est un fichier par source, et l'APK sera
refusé — mais bien plus tard, et sans dire pourquoi."""
def test_the_apk_stays_under_the_zip_entry_limit(self):
"""Le garde-fou de la limite du ZIP, énoncé comme l'invariant qu'il est.
Ce test exigeait un champ « chunk » sur chaque fichier, c'est-à-dire la
disposition en packs. Il échouait donc sur une compilation en archives
alors que la limite y est tenue — il testait la FORME au lieu de ce qui
importe : combien d'entrées le ZIP de l'APK va porter. Un fichier par
source en réclamerait 124 350 pour un plafond de 65 535."""
man = json.loads((self.repos / "manifest.json").read_text())
checked = 0
for proj in man[:5]:
index = self.repos / proj["slug"] / "index.json"
entries = json.loads(index.read_text())
files = [e for e in entries if e.get("type") == "file"]
if not files:
entries_in_apk = 0
files = 0
for proj in man:
index = cbt.index_path(self.repos, proj)
listing = json.loads(index.read_text())
files += len([e for e in listing if e.get("type") == "file"])
entries_in_apk += 1 # l'index lui-même
if cbt.archive_path(self.repos, proj) is not None:
entries_in_apk += 1 # une archive
continue
self.assertTrue(
all("chunk" in e for e in files),
f"{proj['slug']} : des fichiers sans tranche",
entries_in_apk += len(
list((self.repos / proj["slug"]).glob("pack-*.bin"))
)
checked += 1
self.assertGreater(checked, 0, "aucun dépôt à vérifier")
self.assertGreater(files, cbt.MIN_FILES, "aucun dépôt à vérifier")
self.assertLess(
entries_in_apk,
ZIP_ENTRY_LIMIT,
f"{entries_in_apk} entrées dans l'APK pour {files} fichiers",
)
# Et pas seulement « sous la limite » : l'écart doit rester d'un ordre
# de grandeur, sans quoi le prochain dépôt ajouté la ferait sauter.
self.assertLess(entries_in_apk, files // 10)
def test_no_bundled_test_file_lingers_as_a_source(self):
"""Effet de bord mesuré, et il compte : empaquetés, les 1 599 fichiers