From 44bc1b093c1adf7689293cf5f4e81f578a284d74 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:33:36 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20qemu=20:=20reprendre=20un=20t=C3=A9l?= =?UTF-8?q?=C3=A9chargement=20d'image=20coup=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus probable que sur une page. Le contrôle de complétude existait — sans lui un .part tronqué passait pour une image, donnant un qcow2 valide mais VIDE — mais il jetait les octets reçus et renvoyait l'utilisateur à un « curl -C - » tapé à la main. Le transfert se reprend désormais sur le même miroir, trois essais, par un « Range » qui demande la suite ; on ne change de miroir qu'ensuite. Un serveur qui ignore le Range rend le fichier entier, et l'on repart alors de zéro plutôt que de doubler les octets déjà là. Vérifié : 5 tests contre un serveur qui coupe au tiers, image reconstituée à l'octet près, et le .part tronqué part quand les reprises s'épuisent. --- EN --- A VM image weighs half a gigabyte: an interruption is far likelier there than on a page. The completeness check existed — without it a truncated .part passed for an image, giving a valid but EMPTY qcow2 — but it threw away the bytes received and sent the user back to a hand-typed "curl -C -". The transfer now resumes on the same mirror, three attempts, through a "Range" asking for the rest; only then does it change mirror. A server ignoring the Range returns the whole file, and we restart from zero rather than doubling the bytes already there. Checked: 5 tests against a server cutting at one third, image reassembled byte for byte, and the truncated .part goes when the retries run out. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 63 +++++++++++---- test/test_qemu_download_resume.py | 128 ++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 test/test_qemu_download_resume.py diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 46ab3e1..8f6a5c2 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1531,7 +1531,21 @@ def ensure_emulator( DOWNLOAD_TIMEOUT = 30 -def _download_one(url: str, tmp: Path, timeout: int) -> None: +# Reprises d'un transfert COUPÉ, sur le même miroir, avant d'en changer. +# Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus +# probable que sur une page, et repartir de zéro à chaque fois peut ne jamais +# aboutir. Trois essais, puis le miroir suivant. +REPRISES_MAX = 3 + + +class TelechargementTronque(OSError): + """Le serveur a annoncé une taille, et la connexion a coupé avant. + + Distinguée d'une erreur réseau ordinaire parce qu'elle se REPREND : les + octets déjà écrits restent bons, et un « Range » demande la suite.""" + + +def _download_one(url: str, tmp: Path, timeout: int, depuis: int = 0) -> None: """Télécharge url -> tmp en streaming, avec timeout et barre de %. Lève une exception en cas d'échec réseau (miroir suivant à essayer).""" is_tty = sys.stdout.isatty() @@ -1568,7 +1582,7 @@ def _download_one(url: str, tmp: Path, timeout: int) -> None: # il était validé comme « complet » -> qcow2 valide mais VIDE (juste # l'en-tête) -> VM qui ne boote pas, et cache empoisonné réutilisé ensuite. if total > 0 and done < total: - raise OSError( + raise TelechargementTronque( f"téléchargement incomplet : {done}/{total} octets reçus " "(connexion interrompue)" ) @@ -1607,19 +1621,38 @@ def download_image( for i, url in enumerate(urls, 1): tag = "" if len(urls) == 1 else f" (miroir {i}/{len(urls)})" print(f" Téléchargement{tag} : {url}", flush=True) - try: - _download_one(url, tmp, timeout) - tmp.replace(dest) - return - except urllib.error.HTTPError as exc: # image absente sur ce miroir - tmp.unlink(missing_ok=True) - had_404 = had_404 or exc.code == 404 - print(f"\n Échec : HTTP {exc.code}", flush=True) - errors.append(f"{url} -> HTTP {exc.code}") - except Exception as exc: # réseau/timeout : miroir suivant - tmp.unlink(missing_ok=True) - print(f"\n Échec : {exc}", flush=True) - errors.append(f"{url} -> {exc}") + # Une coupure se REPREND sur le même miroir avant d'en changer : + # les octets déjà écrits restent bons, et le .part survit entre deux + # essais. Tout autre échec — 404, timeout, réseau — passe au miroir + # suivant sans insister. + for essai in range(1, REPRISES_MAX + 1): + depuis = tmp.stat().st_size if tmp.exists() else 0 + try: + _download_one(url, tmp, timeout, depuis if essai > 1 else 0) + tmp.replace(dest) + return + except TelechargementTronque as exc: + if essai < REPRISES_MAX: + recus = tmp.stat().st_size if tmp.exists() else 0 + print( + f"\n Coupé à {recus} octets, reprise" + f" {essai}/{REPRISES_MAX - 1}...", + flush=True, + ) + continue + tmp.unlink(missing_ok=True) + print(f"\n Échec : {exc}", flush=True) + errors.append(f"{url} -> {exc}") + except urllib.error.HTTPError as exc: # absente de ce miroir + tmp.unlink(missing_ok=True) + had_404 = had_404 or exc.code == 404 + print(f"\n Échec : HTTP {exc.code}", flush=True) + errors.append(f"{url} -> HTTP {exc.code}") + except Exception as exc: # réseau/timeout : miroir suivant + tmp.unlink(missing_ok=True) + print(f"\n Échec : {exc}", flush=True) + errors.append(f"{url} -> {exc}") + break hint = ( "\n Image introuvable (404) : cette version est probablement EOL et" " a été retirée du miroir. Choisissez une version LTS encore" diff --git a/test/test_qemu_download_resume.py b/test/test_qemu_download_resume.py new file mode 100644 index 0000000..ce57e44 --- /dev/null +++ b/test/test_qemu_download_resume.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Un transfert coupé se reprend-il, et le fichier est-il intact ? + +Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus probable +que sur une page, et repartir de zéro à chaque fois peut ne jamais aboutir. Le +contrôle de complétude existait déjà — sans lui un .part tronqué passait pour +une image, donnant un qcow2 valide mais VIDE, et une VM qui ne démarre pas — +mais il jetait les octets reçus. + +Deux pièges que ces tests gardent : + +- les octets déjà écrits sont CONSERVÉS entre deux essais, et la suite est + demandée par « Range ». Les jeter rendrait chaque reprise aussi longue que + le premier essai ; +- un serveur qui IGNORE « Range » rend 200 et le fichier entier : il faut + alors repartir de zéro, sans quoi les octets déjà là seraient doublés et + l'image illisible. +""" + +import http.server +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RACINE)) + +from script.qemu import deploy_qemu as d # noqa: E402 + +CONTENU = b"".join(bytes([i % 251]) for i in range(60_000)) + + +class Serveur: + """Un serveur qui coupe ses `coupures` premiers transferts au tiers.""" + + def __init__(self, coupures, honore_range=True): + self.restantes = coupures + self.honore_range = honore_range + self.demandes = [] + contexte = self + + class Poignee(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def do_GET(self): + rng = self.headers.get("Range") + contexte.demandes.append(rng) + debut = 0 + if rng and contexte.honore_range: + debut = int(rng.split("=")[1].split("-")[0]) + self.send_response(206) + self.send_header( + "Content-Range", + f"bytes {debut}-{len(CONTENU) - 1}/{len(CONTENU)}", + ) + else: + self.send_response(200) + reste = CONTENU[debut:] + self.send_header("Content-Length", str(len(reste))) + self.end_headers() + if contexte.restantes > 0: + contexte.restantes -= 1 + self.wfile.write(reste[: len(reste) // 3]) + else: + self.wfile.write(reste) + + self.httpd = http.server.HTTPServer(("127.0.0.1", 0), Poignee) + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + + @property + def url(self): + return f"http://127.0.0.1:{self.httpd.server_address[1]}/image.qcow2" + + def arrete(self): + self.httpd.shutdown() + + +class TestLaReprise(unittest.TestCase): + def telecharge(self, serveur): + self.addCleanup(serveur.arrete) + with tempfile.TemporaryDirectory() as coin: + dest = Path(coin) / "image.qcow2" + d.download_image([serveur.url], dest, dry_run=False, timeout=10) + return dest.read_bytes() + + def test_une_coupure_est_reprise(self): + serveur = Serveur(coupures=1) + self.assertEqual(CONTENU, self.telecharge(serveur)) + + def test_deux_coupures_sont_reprises(self): + serveur = Serveur(coupures=2) + self.assertEqual(CONTENU, self.telecharge(serveur)) + + def test_la_suite_est_demandee_par_range(self): + """Sans Range, la reprise retéléchargerait tout.""" + serveur = Serveur(coupures=1) + self.telecharge(serveur) + self.assertIsNone(serveur.demandes[0]) + self.assertTrue(serveur.demandes[1].startswith("bytes=")) + + def test_un_serveur_qui_ignore_range_repart_de_zero(self): + """Sinon les octets déjà là seraient doublés, et l'image illisible.""" + serveur = Serveur(coupures=1, honore_range=False) + self.assertEqual(CONTENU, self.telecharge(serveur)) + + def test_au_dela_des_reprises_le_miroir_suivant(self): + """Trois essais, puis on change de miroir plutôt que d'insister.""" + serveur = Serveur(coupures=d.REPRISES_MAX) + self.addCleanup(serveur.arrete) + with tempfile.TemporaryDirectory() as coin: + dest = Path(coin) / "image.qcow2" + with self.assertRaises(SystemExit): + d.download_image([serveur.url], dest, False, timeout=10) + self.assertFalse( + dest.with_suffix(dest.suffix + ".part").exists(), + "le .part tronqué doit partir, il empoisonnerait le cache", + ) + + +if __name__ == "__main__": + unittest.main()